Playwright 浏览器自动化、端到端测试与 Playwright MCP
1. 概述
Playwright(发音 /ˈpleɪˌraɪt/)是微软(Microsoft)开源的一个端到端(E2E, End-to-End)浏览器自动化与测试框架。它通过统一的 API 控制 Chromium、Firefox 和 WebKit 三大浏览器引擎,可用于 UI 自动化测试、爬虫、截图、接口测试与组件测试。
sleep/wait 导致测试不稳定(flaky)。Playwright 用 自动等待(Auto-waiting) 与 Web-First 断言 让测试代码"简单、可靠、零脆弱"。
Playwright 提供三种使用形态:
| 形态 | 包 | 用途 |
|---|---|---|
| Playwright Library(库) | playwright | 手动 launch 浏览器、创建 Context、写脚本,适合爬虫与自动化任务 |
| Playwright Test(测试框架) | @playwright/test | 内置 Test Runner、Fixtures、Web-First 断言、并行、报告等全套 E2E 测试能力 |
| Playwright MCP(MCP 服务器) | @playwright/mcp | 把 Playwright 封装成 MCP 工具,让大模型(Agent)自己驱动浏览器,详见第 6 章 |
官方支持语言:JavaScript / TypeScript(主力)、Python、Java、.NET。支持桌面与移动端设备模拟,本地或 CI(持续集成)运行,headless(无头)或有头模式。
1.1 核心特性
- 三大浏览器引擎统一 API——同一份测试可在 Chromium / Firefox / WebKit 上运行,覆盖 Chrome、Edge、Safari 生态。
- 自动等待(Auto-waiting)——每个 action 前自动检查元素是否可见、稳定、可接收事件,消除手工 sleep。
- Web-First 断言——断言会持续重试直到条件满足,天然防 flaky。
- 隔离的浏览器上下文(BrowserContext)——每条测试拥有独立的"隐身模式"环境,互不污染。
- 全套工具链——Codegen(录制生成代码)、Trace Viewer(回放调试)、UI Mode(可视化调试)、HTML 报告、截图/录屏。
- 强大的拦截能力——路由拦截(Mock API)、模拟设备/地理/时区/权限、加载存储状态(免登录)。
1.2 Playwright 与网页 JavaScript 的区别
Playwright 是"用脚本从浏览器外部控制浏览器"的自动化工具,而网页 JavaScript 是"在浏览器内部运行的页面脚本"。两者运行位置不同,控制能力也不同:
| 维度 | 网页 JS(DOM 脚本) | Playwright 脚本 |
|---|---|---|
| 运行位置 | 浏览器内部,在页面沙箱中执行 | 浏览器外部(Node.js / Python 等进程),通过 CDP / Juggler 协议遥控浏览器 |
| 控制范围 | 只能操作当前已加载页面的 DOM | 能启动/关闭浏览器、开多个标签页、创建隔离 Context、拦截网络、截图录屏 |
| 权限限制 | 受同源策略(Same-Origin Policy)约束 | 无同源限制,可跨站点操作、模拟键盘鼠标、Mock 接口 |
| 主要用途 | 实现应用自身的交互逻辑(按钮、表单、动画) | 端到端测试、爬虫、UI 自动化、批量截图 |
| 执行者视角 | "房间里的人,只能操作这一间房" | "楼外的总控,用对讲机指挥整栋楼的保安系统" |
page.evaluate() 往页面注入 JS——但那只是它遥控浏览器的手段之一,不代表它本身就是网页 JS。网页 JS 只能做应用自己的交互,Playwright 则把它当成被操控的对象。
// 网页 JS:页面内部代码,只能操作当前页面
document.querySelector('#submit').addEventListener('click', () => {
document.getElementById('result').textContent = 'done';
});
// Playwright 脚本:进程外代码,遥控整个浏览器
const { chromium } = require('playwright');
(async () => {
const browser = await chromium.launch();
const page = await browser.newPage();
await page.goto('https://example.com');
await page.getByRole('button', { name: 'Submit' }).click(); // 真实模拟用户点击
await page.screenshot({ path: 'after.png' }); // 浏览器外发起的截图
await browser.close();
})();
2. 核心概念
理解 Playwright 先理解它的对象模型层级,从大到小为:Playwright → BrowserType → Browser → BrowserContext → Page → Locator。
2.1 BrowserType 与 Browser
BrowserType(浏览器类型)是 chromium、firefox、webkit 三个入口对象,负责 launch() 启动浏览器。启动后得到 Browser(浏览器实例),一个 Browser 可创建多个相互隔离的 BrowserContext。
headless: false 显示窗口便于调试。
2.2 BrowserContext(浏览器上下文)
BrowserContext 相当于一个"隐身窗口/全新浏览器档案",拥有独立的 localStorage、sessionStorage、cookies、缓存与权限。它是 Playwright 实现测试隔离的核心。每一条测试默认获得一个全新的 Context。
2.3 Page(页面)
Page 代表一个浏览器标签页,提供 goto()、locator()、screenshot() 等操作。一个 Context 可以有多个 Page(可模拟多页签/多用户场景)。
2.4 Locator(定位器)
Locator 是 Playwright 最推荐的元素定位方式,它不直接持有 DOM 元素,而是"定位规则"。在任何时刻解析元素,天然支持重试与动态页面。推荐的定位优先顺序:用户可见性(getByRole)→ 文本(getByText)→ 标签(getByLabel)→ 测试 ID(getByTestId)→ CSS/XPath。
2.5 Fixture(测试夹具)
Fixture 是 Playwright Test 的核心机制:为每条测试惰性初始化环境(如 page、context、browser),测试用完自动清理。可自定义、可组合、可依赖,替代传统 before/after 钩子。
2.6 Auto-waiting 与 Actionability
执行 click()、fill() 等动作前,Playwright 自动等待元素满足"可操作性"(Actionability)检查:可见、稳定(连续两帧不动)、接收事件(不被遮挡)、可用(enabled)、可编辑(editable)。超时则抛出 TimeoutError。
2.7 Web-First Assertions(Web 优先断言)
以 await expect(locator).toBeVisible() 形式书写,断言会自动重试直到条件满足或超时,从而消除脆弱的等待逻辑。
2.8 专有名词速览
| 术语(英文) | 解释 |
|---|---|
| E2E Test(端到端测试) | 在真实浏览器中模拟真实用户操作,验证整个应用链路 |
| Headless(无头模式) | 浏览器不显示窗口,后台运行,常用于 CI |
| Flaky Test(不稳定测试) | 同一份代码时而通过时而失败,多因缺少等待或依赖共享状态 |
| CDP(Chrome DevTools Protocol) | Chromium 系浏览器的调试控制协议,Playwright 通过它驱动 Chromium/Chrome/Edge |
| Juggler | Playwright 自研的 Firefox / WebKit 调试协议(补丁化的专用协议) |
| Worker(工作进程) | Test Runner 并行运行的进程,每个 Worker 顺序执行一批测试 |
| Project(测试项目) | config 中定义的一组配置(浏览器、设备、设置),同一测试可在多个 Project 下跑 |
| Sharding(分片) | 把测试集按机器拆分并行执行,缩短 CI 总时长 |
| Trace(追踪) | 记录测试期间的页面快照、网络、控制台与操作,供 Trace Viewer 回放 |
| Codegen(代码生成) | 通过操作真实浏览器自动录制生成测试代码 |
| POM(Page Object Model) | 页面对象模型,把页面操作封装成类,提升复用与可维护性 |
3. 行为图(系统"做什么")
3.1 用例图——Playwright 整体能力
展示围绕 Playwright 的各角色(开发者、测试工程师、CI/CD 系统、被测浏览器)与其核心用例。
用例解读:开发者负责安装、编写与调试测试;测试工程师可借助 Codegen 录制脚本、用 UI Mode 可视化调试;CI/CD 系统无头批量运行全部项目并产出报告;所有交互最终都由 Playwright 驱动真实浏览器完成。
3.2 活动图——从编写测试到 CI 集成的完整流程
展示一条 E2E 测试从编写、本地运行、调试修复,到推入 CI 跨浏览器执行的端到端工作流(带泳道)。
3.3 时序图——单条测试的执行内部流程
展示 Test Runner 运行一条测试时,Fixture 系统、驱动层与浏览器进程的协作细节,重点体现"每次运行都是全新隔离环境"。
3.4 状态机图——测试用例生命周期
展示一条测试用例在 Test Runner 中的状态流转,包含跳过、执行、通过/失败、超时以及失败重试(flaky)等状态。
4. 结构图(系统"由什么组成")
4.1 部署图——运行时环境拓扑
展示 Playwright 在开发机与 CI 服务器上的部署形态,以及浏览器二进制、被测应用与测试产物之间的部署关系。
部署要点:浏览器二进制默认缓存在 ~/.cache/ms-playwright(Linux)/ ~/Library/Caches/ms-playwright(macOS)/ %USERPROFILE%\AppData\Local\ms-playwright(Windows)。CI 上用 npx playwright install --with-deps 一键安装浏览器及系统依赖。
4.2 组件图——分层组件与协议适配
展示 Test Runner、Library、playwright-core、协议适配层与浏览器进程之间的组件依赖,揭示"统一 API 如何驱动三种浏览器"。
4.3 CDP 协议详解
CDP(Chrome DevTools Protocol,Chrome 开发者工具协议)是 Chromium/Chrome 提供的官方调试协议,用于外部进程对浏览器进行"遥控"——从 DevTools 面板到 Puppeteer、Playwright 等自动化工具,底层都走 CDP。它是 Playwright 驱动 Chromium 系浏览器(含 Chrome、Edge)的通信通道。
4.3.1 CDP 的消息模型
CDP 本质是一个 JSON-RPC(远程过程调用)协议:客户端向浏览器发"命令"(Command/Method),浏览器回"响应"(Response);浏览器主动上报"事件"(Event)。消息通过 WebSocket(远程调试端口)或 管道(pipe,本地进程)传输。
| 消息类型 | 格式 | 说明 |
|---|---|---|
| 命令 Request | { "id": 1, "method": "Page.navigate", "params": { "url": "..." } } | 客户端请求,id 用于匹配响应 |
| 响应 Response | { "id": 1, "result": { "frameId": "..." } } 或 { "id": 1, "error": {...} } | 浏览器对命令的应答 |
| 事件 Event | { "method": "Page.frameNavigated", "params": {...} } | 浏览器主动推送(如导航、网络、DOM 变化) |
4.3.2 CDP 的领域划分(Domain)
CDP 把浏览器能力按功能划分成若干 Domain(领域),每个 Domain 下有成组的命令与事件,类似命名空间。Playwright 的大部分 API 就是对这些命令的封装。
| Domain | 职责 | 对应 Playwright 能力 |
|---|---|---|
Page | 页面导航、加载状态、截图 | page.goto()、page.screenshot() |
Runtime | JavaScript 求值、控制台 | page.evaluate() |
DOM | DOM 查询与操作 | locator() 底层查询 |
Input | 鼠标/键盘/触摸事件 | locator.click()、press() |
Network | 请求/响应、拦截、模拟 | page.route()、Mock API |
Emulation | 设备/视口/地理/时区模拟 | devices['iPhone 12']、geolocation |
Target | 标签页/Target 管理 | context.newPage()、多标签 |
Accessibility | 可访问性树 | getByRole() 定位 |
Tracing | 性能追踪 | Trace Viewer 数据来源 |
4.3.3 为什么不直接用 CDP?
- CDP 只属于 Chromium 系——Firefox、WebKit 没有等价的稳定官方协议。Playwright 为此为它们补丁了 Juggler 协议,以统一三套引擎的行为。
- CDP 是"裸协议",不处理状态——它返回原始数据,需要自己拼装等待/重试/断言逻辑。Playwright 在其上封装了 自动等待、Web-First 断言、定位器 等高级能力。
- 无需直接上手 CDP——只有少数高级场景(如绕过封装做底层操作)才需要手写 CDP 命令。
4.3.4 如何直接使用 CDP
通过 page.context().newCDPSession(page) 可拿到一个 CDP 会话,直接 send() 命令、监听事件:
const { chromium } = require('playwright');
(async () => {
const browser = await chromium.launch();
const page = await browser.newPage();
// 建立 CDP 会话
const session = await page.context().newCDPSession(page);
// 启用网络域,监听响应事件
await session.send('Network.enable');
session.on('Network.responseReceived', ({ response }) => {
console.log('状态码:', response.status, 'URL:', response.url);
});
await page.goto('https://example.com');
await browser.close();
})();
CDP 专有名词:JSON-RPC(一种基于 JSON 的远程调用协议)、WebSocket(浏览器/服务端全双工通信通道)、Command/Response(命令/响应)、Event(浏览器主动事件)、Domain(协议功能域)、Session(CDP 会话,绑定到特定 Target)、Target(浏览器中的一个可调试目标,如标签页)。
4.4 包图——npm 包与依赖关系
展示 Playwright 相关 npm 包(@playwright/test、playwright、playwright-core、@playwright/browser-*)的组成与依赖。
包选型:做 E2E 测试用 @playwright/test;只做爬虫/自动化用 playwright。两者底层都依赖 playwright-core。@playwright/browser-chromium 等辅助包会在 npm install 时自动下载对应浏览器。
4.5 类图——核心 API 对象模型
展示 Playwright 编程模型中最核心的类及其关系,也是读者写代码时最常打交道的对象。
5. 实战速查手册
5.1 安装与初始化
# 初始化新项目(交互式选择 TS/JS、测试目录、是否装浏览器) npm init playwright@latest # 在已有项目中安装 npm install -D @playwright/test # 安装浏览器二进制(chromium / firefox / webkit)及系统依赖 npx playwright install --with-deps # 安装指定浏览器 npx playwright install webkit
5.2 CLI 常用命令
| 命令 | 说明 |
|---|---|
npx playwright test | 运行全部测试(headless、默认并行) |
npx playwright test tests/example.spec.ts | 运行指定文件 |
npx playwright test --headed | 有头模式,显示浏览器窗口 |
npx playwright test --project=firefox | 只跑指定浏览器项目 |
npx playwright test --ui | 打开 UI Mode 可视化调试 |
npx playwright test --debug | 断点调试(配合 Inspector) |
npx playwright test --grep "keyword" | 按标题/标签过滤测试 |
npx playwright test --shard=1/2 | 分片运行(第 1/2 片) |
npx playwright test --last-failed | 只重跑上次失败的测试 |
npx playwright codegen url | 打开浏览器录制生成测试代码 |
npx playwright show-report | 打开上一次的 HTML 报告 |
npx playwright show-trace trace.zip | 打开 Trace Viewer 回放追踪 |
5.3 最常用 Locator 写法
page.getByRole('button', { name: 'Submit' }) // 按 ARIA 角色(最推荐,贴近用户视角)
page.getByRole('heading', { name: 'Home' })
page.getByText('Hello World') // 按文本
page.getByLabel('User Name') // 按表单 label(自带可访问性)
page.getByPlaceholder('请输入邮箱') // 按 placeholder
page.getByTestId('submit-btn') // 按 data-testid(最稳)
page.getByTitle('Close') // 按 title 属性
page.locator('input[name="email"]') // 按 CSS
page.locator('xpath=//button[@id="go"]') // 按 XPath(最后手段)
page.frameLocator('#iframe').getByRole(...) // 进入 iframe 内部定位
5.4 最常用 Web-First 断言
await expect(page).toHaveTitle(/Playwright/); // 页面标题
await expect(page).toHaveURL('https://example.com'); // 页面 URL
await expect(locator).toBeVisible(); // 可见
await expect(locator).toBeHidden(); // 隐藏
await expect(locator).toBeEnabled(); // 可用
await expect(locator).toBeChecked(); // 已勾选
await expect(locator).toHaveText('Login'); // 精确文本
await expect(locator).toContainText('Wel'); // 包含文本
await expect(locator).toHaveValue('foo'); // 输入框值
await expect(locator).toHaveCount(3); // 元素个数
await expect(locator).toHaveAttribute('href', '/docs'); // 属性
await expect(response).toBeOK(); // 响应 2xx/3xx
5.5 常用 Action 动作
await page.goto('https://example.com'); // 导航
await locator.click(); // 单击
await locator.dblclick(); // 双击
await locator.fill('text'); // 输入(清空后输入)
await locator.press('Enter'); // 按键
await locator.check() / uncheck(); // 勾选/取消勾选
await locator.selectOption('optionValue'); // 下拉选择
await locator.setInputFiles('file.png'); // 文件上传
await locator.hover(); // 悬停
await locator.dragTo(targetLocator); // 拖拽
await locator.screenshot({ path: 'el.png' }); // 元素截图
await page.screenshot({ path: 'page.png' }); // 整页截图
await page.waitForTimeout(1000); // 仅调试用,生产代码慎用
5.6 配置文件要点(playwright.config.ts)
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests', // 测试目录
timeout: 30_000, // 单测超时
retries: 2, // 失败重试次数(CI 上常用)
workers: 4, // 并行 Worker 数
reporter: [['list'], ['html']], // 终端列表 + HTML 报告
use: {
baseURL: 'https://example.com', // 测试内相对路径的基准
trace: 'on-first-retry', // 首次重试时记录 Trace
screenshot: 'only-on-failure', // 失败时截图
video: 'retain-on-failure', // 失败时保留录屏
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
{ name: 'webkit', use: { ...devices['Desktop Safari'] } },
{ name: 'Mobile Chrome', use: { ...devices['Pixel 5'] } },
{ name: 'Mobile Safari', use: { ...devices['iPhone 12'] } },
],
webServer: { // 测试前自动启动被测应用
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
});
5.7 一个完整的测试示例
import { test, expect } from '@playwright/test';
test('登录后可看到欢迎语', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('用户名').fill('brian');
await page.getByLabel('密码').fill('secret');
await page.getByRole('button', { name: '登录' }).click();
await expect(page.getByTestId('welcome')).toContainText('欢迎回来');
await expect(page).toHaveURL(/\/home/);
});
5.8 Fixture 与 Page Object Model(POM)
把页面封装成类(POM),用 test.extend() 注册为 Fixture,实现复用与隔离:
// todo-page.ts —— 页面对象
export class TodoPage {
constructor(public readonly page: Page) {}
async goto() { await this.page.goto('/todos'); }
async add(text: string) {
await this.page.getByPlaceholder('What needs to be done?').fill(text);
await this.page.getByPlaceholder('What needs to be done?').press('Enter');
}
}
// my-test.ts —— 注册为 Fixture
export const test = base.extend<{ todoPage: TodoPage }>({
todoPage: async ({ page }, use) => {
const tp = new TodoPage(page);
await tp.goto();
await use(tp); // use 之后自动清理
},
});
// todo.spec.ts —— 直接使用
test('添加待办', async ({ todoPage, page }) => {
await todoPage.add('buy milk');
await expect(page.getByText('buy milk')).toBeVisible();
});
5.9 调试技巧
| 场景 | 手段 |
|---|---|
| 想逐步观察页面 | --headed 有头运行;--slow-mo=500 放慢 |
| 定位器选不准元素 | UI Mode 中点元素看高亮;或 page.locator(sel).count() 排查数量 |
| 需要断点 | --debug 打开 Inspector;代码里 await page.pause() |
| 复盘失败过程 | Trace Viewer 回放 trace.zip,查看每一步快照、网络与控制台 |
| 环境变量进入调试 | PWDEBUG=1 npx playwright test |
| 只跑失败用例 | --last-failed;或 --grep 关键字过滤 |
5.10 常见反模式与建议
| 反模式 | 问题 | 正确做法 |
|---|---|---|
waitForTimeout(3000) 硬等待 | 固定时间等待,不稳定 | 依赖自动等待与 Web-First 断言 |
| 用 CSS 复杂选择器定位 | 易受重构影响 | 优先 getByRole/getByTestId |
| 测试间共享登录态/数据 | 状态泄漏导致偶发失败 | 每条测试独立 Context;用 storageState 复用登录态 |
| 把断言写在 action 后面用 try/catch | 掩盖真实错误 | 使用 await expect(...) 声明式断言 |
| 测试代码重复的页面操作 | 维护成本高 | 抽取 POM + Fixture 复用 |
| 大量并发依赖外部真实服务 | 慢且不稳定 | 用 page.route() 拦截 Mock 接口 |
5.11 Mock 接口示例
test('拦截并返回假数据', async ({ page }) => {
await page.route('**/api/user', route =>
route.fulfill({ json: { name: 'Brian', vip: true } })
);
await page.goto('/profile');
await expect(page.getByTestId('user-name')).toHaveText('Brian');
});
5.12 登录态复用(免每次登录)
// 首次:录制登录态
npx playwright codegen --save-storage=auth.json https://example.com/login
// config 中:注入 storageState,测试直接带登录态
export default defineConfig({
use: {
storageState: 'auth.json',
},
});
6. Playwright MCP
6.1 什么是 MCP
MCP(Model Context Protocol,模型上下文协议)是 Anthropic 于 2024 年 11 月开源的开放协议,用来标准化「大模型应用」与「外部工具 / 数据」之间的连接方式。官方常打一个比方:MCP 之于 AI 应用,就像 USB-C 之于外设——只要实现同一个协议,任何客户端都能插上任何服务器。
它本身与 Playwright 无关,是通用协议;Playwright MCP 只是众多 MCP 服务器中的一个(同类的还有文件系统、GitHub、数据库、Slack 等)。协议里有三个角色:
| 角色 | 职责 | 例子 |
|---|---|---|
| Host(宿主) | 运行大模型、发起会话的应用程序 | OpenCode、Claude Desktop、VS Code、Cursor |
| Client(客户端) | Host 内部的连接器,一个 Server 对应一个 Client,负责握手与消息收发 | 由 Host 内置,使用者无需实现 |
| Server(服务器) | 把某项能力通过协议暴露出去的程序 | @playwright/mcp |
协议定义了三种能力原语(primitive):
- Tools(工具)——模型主动调用的动作,如
browser_click。带 JSON Schema 描述参数,模型据此生成调用。 - Resources(资源)——应用读取的上下文数据(文件、记录、页面内容),由客户端决定何时放进上下文。
- Prompts(提示模板)——用户主动触发的预设提示词,如斜杠命令。
另有若干可选能力:Sampling(服务器反过来请求模型生成)、Roots(告知服务器可访问的目录范围)、Elicitation(服务器向用户追问信息)。传输层默认用 stdio(本地子进程的标准输入输出),也可用 Streamable HTTP / SSE 走网络;消息格式统一为 JSON-RPC 2.0。会话生命周期大致是:initialize(协商能力)→ tools/list(取工具定义)→ tools/call(反复调用)→ 通知与关闭。
6.2 架构与通信
以本机实际配置为例(OpenCode + Playwright MCP + 一个已经打开的 Chrome):
关键点:
- 大模型从不直接操作浏览器,它只生成「调用哪个工具、参数是什么」的结构化请求。
- 真正的点击与输入由 MCP Server 翻译成 Playwright API(
locator.click()等),再经 CDP 协议下发给浏览器。 - Server 与浏览器的关系有两种:自己启动一个干净浏览器,或连接你已经打开的浏览器(
--cdp-endpoint)。后者直接复用你的登录态与标签页——方便,但也意味着操作的是你的真实浏览器。注意 Chrome 136+ 上这条路的接入方式变了,见 6.6.1。
6.3 与 Playwright Library / Test / CLI 的关系
同一个 Playwright 引擎,有四种出口。区别不在于「能不能做」,而在于谁在调用、以及被调用方的「结果」放进哪里:
| 形态 | 谁在调用 | 交互方式 | 上下文里常驻什么 | 典型场景 |
|---|---|---|---|---|
| Playwright Library | 程序员 | 写 Node / Python 代码 | 你自己的源码 | 爬虫、批量自动化脚本 |
| Playwright Test | 程序员 / CI | npx playwright test | 测试报告 | E2E 回归测试 |
| Playwright MCP | 大模型(Agent) | 模型生成工具调用 | 25+ 个工具的 JSON Schema + 每一步的完整无障碍树(且旧的一直留着) | 探索式自动化、一次性任务、需要边看页面边判断的流程 |
| playwright-cli + Skills | 编程 Agent | Agent 执行 shell 命令 | 一份 Skill 说明 + 命令输出摘要(快照落盘,不进上下文) | 大量重复操作、上下文吃紧的编程 Agent、CI |
这句话怎么落地?6.8 拆机制与用法,6.9 用同一个任务做实测对照,6.10 给选型判据。
6.4 核心机制:无障碍快照(accessibility snapshot)与 ref
这是 Playwright MCP 与「截图 + 视觉模型」路线最本质的区别:它不「看」像素,而是读无障碍树。
// browser_snapshot 返回的是这种结构化文本(示意)
- generic [ref=e3]:
- banner:
- link "百度首页" [ref=e8]
- main:
- textbox "搜索" [ref=e12]
- button "百度一下" [ref=e14]
- 每个可交互元素带一个
ref(如e12),它是快照内的稳定短标识;browser_click的target就填这个 ref(也可以直接给选择器)。 - 元素按角色(role)与可访问名称命名,模型理解的是「一个叫『百度一下』的按钮」,而不是「坐标 (430, 520) 的像素块」。这让操作确定、可复现、不受窗口尺寸影响。
- 对比截图方案:省 token、无需视觉模型、不会因渲染漂移;代价是丢失纯视觉信息(图表、Canvas 绘图、验证码、装饰性布局)。
- 快照只覆盖无障碍树中的「语义」元素。用
div拼出来的假按钮若没有role/aria-*属性,在树里可能只是generic,定位不到——此时要用browser_find搜文本,或退到browser_evaluate直接查 DOM。 - 虚拟滚动列表只包含可见部分(本机实测一屏约 22 行);视口外的行不在快照里,必须先滚动再看。
browser_take_screenshot只能给人看,不能据此操作。官方描述写得很直白:You can't perform actions based on the screenshot, use browser_snapshot for actions。
6.5 工具清单
当前版本(@playwright/mcp@0.0.83)默认暴露约 25 个工具,其余需用 --caps 显式开启。分组如下:
| 分组 | 主要工具 | 说明 |
|---|---|---|
| 核心自动化 (默认) | browser_navigate、browser_click、browser_type、browser_hover、browser_select_option、browser_press_key、browser_file_upload、browser_drag、browser_wait_for、browser_navigate_back、browser_close | 基本交互动作,参数多为 target(ref 或选择器)+ element(人类可读描述,用于请求授权) |
| 观测类 | browser_snapshot、browser_find、browser_console_messages、browser_network_requests | browser_find 只在快照里搜文本 / 正则,比整棵快照省 token |
| 执行代码 | browser_evaluate、browser_run_code_unsafe | 在页面里跑 JS / 跑 Playwright 代码,可绕过定位限制 |
| 标签页 | browser_tabs | list / new / close / select |
| 窗口与弹窗 | browser_resize、browser_handle_dialog | 改变视口、处理 alert / confirm |
--caps=storage | browser_cookie_*、browser_localstorage_*、browser_sessionstorage_*、browser_storage_state | 读写 Cookie 与存储,可做登录态复用 |
--caps=network | browser_route、browser_unroute、browser_route_list、browser_network_state_set | 拦截 / Mock 请求 |
--caps=devtools | browser_highlight、browser_annotate、browser_start_tracing、browser_start_video、browser_resume | 高亮元素、录屏、录制 trace(调试用) |
--caps=vision | browser_mouse_click_xy、browser_mouse_move_xy、browser_mouse_wheel 等 | 基于坐标的鼠标操作,用于无障碍树覆盖不到的界面(Canvas 等) |
--caps=pdf / testing | browser_pdf_save;browser_verify_*、browser_generate_locator | 导出 PDF;断言与定位器生成 |
6.6 安装与配置
# 直接用 npx 启动(MCP 客户端会自动拉起,通常无需手动运行) npx @playwright/mcp@latest # 常用参数 --cdp-endpoint=http://127.0.0.1:9222 # 连接已打开的 Chrome(需非默认 --user-data-dir,见下) --cdp-endpoint=chrome # 直接连日常 Chrome 的 channel(需在 chrome://inspect 勾选允许) --headless / --browser chrome # 无头模式 / 指定浏览器 --isolated # 内存中的临时 profile,不落盘 --user-data-dir <path> # 指定持久化 profile 目录 --caps=devtools,network,storage,vision # 开启额外能力组 --init-script <file.js> # 每个页面加载前注入脚本(可用于打 hook) --timeout-action 5000 # 单动作超时,默认 5000ms --port 8931 # 改为 HTTP / SSE 传输(远程或多客户端共用)
本机 OpenCode 的实际配置(~/.config/opencode/opencode.json):
"mcp": {
"playwright": {
"type": "local",
"command": ["npx", "-y", "@playwright/mcp@latest",
"--cdp-endpoint=http://127.0.0.1:9222"],
"enabled": true
}
}
客户端配置的两种形态:type: local(默认,走 stdio 子进程),或远程 url: "http://localhost:8931/mcp"(走 HTTP)。多客户端共用时可加 --shared-browser-context。
6.6.1 连接现有 Chrome:Chrome 136+ 的硬限制
--remote-debugging-port 默认 profile 不再生效。官方安全改动:这两个开关(含 --remote-debugging-pipe)在默认用户数据目录上被忽略,必须同时指定非默认的 --user-data-dir;否则端口根本不会监听,连上去只会得到 ECONNREFUSED。所以「先开日常 Chrome,再用 --cdp-endpoint=http://127.0.0.1:9222 连上去」这条老办法,在 Chrome 136+ 上默认走不通。目的是防止恶意程序借调试端口读取 Cookie——注意它保护的是「默认目录」,不是「Chrome 这个程序」。
| 你想达到的目标 | 现在可行的做法 | 代价 / 限制 |
|---|---|---|
| 复用日常 Chrome(登录态、扩展、已开标签页) | 在 Chrome 打开 chrome://inspect/#remote-debugging,勾选 Allow remote debugging for this browser instance;然后 MCP 用 --cdp-endpoint=chrome,CLI 用 playwright-cli attach --cdp=chrome | 每个浏览器实例手动勾选一次(重启 Chrome 后失效);Chrome 144+ 首次连接还会弹一次 Allow 授权框;被控期间顶部有「Chrome 正被自动化测试软件控制」横幅;且等于把整个浏览器(含 Cookie)交给自动化。未勾选时 CLI 会直接报 DevToolsActivePort file not found |
| 复用日常 Chrome,但不开放调试端口 | 安装 Playwright Extension(Chrome 应用商店),MCP 加 --extension,CLI 用 playwright-cli attach --extension=chrome | 需要装扩展;浏览器会有调试/控制提示;能力受扩展授权范围约束,但仍复用真实登录态,适合 SSO / 2FA / 依赖扩展的页面 |
| 无人值守 / CI / 稳定可重复 | 独立 profile 起 Chrome:--remote-debugging-port=9222 --user-data-dir=~/.chrome-debug;或干脆用 Playwright 自带 Chromium(open)或 Chrome for Testing | 是另一个 profile,站点要重新登录一次;但没有弹窗、没有横幅、可脚本化,登录态可用 state-save / state-load 复用 |
另外两个变化:--cdp-endpoint 现在支持直接写 channel 名(chrome / chrome-canary / msedge …),由 Playwright 去读该 profile 下 Chrome 写出的 DevToolsActivePort 文件,不必自己拼端口号;playwright-cli list --all 会把本机可 attach 的 channel、profile 目录和推荐命令直接列出来,排查「连不上」时先跑它。
opencode.json 里写的是 --cdp-endpoint=http://127.0.0.1:9222,它只在「已有进程监听 9222」时有效。如果平时并没有常驻这样一个调试实例,应改成 --cdp-endpoint=chrome(配合 chrome://inspect 勾选),否则 MCP 会在连接阶段直接失败。
--timeout-action 默认 5000ms,正是第 7 章里「点击 5 秒超时」的时间来源;而 --init-script 可以让注入脚本在每次页面加载前自动执行,正好解决第 7 章提到的「location.reload() 后注入失效」问题。
6.7 局限与选型
- 不是安全边界(官方原话:Playwright MCP is not a security boundary)。它有能力操作你浏览器里的一切,包括已登录的账号;连接真实浏览器时务必清楚这一点。
- token 成本高:每次操作后的无障碍树都要进入上下文,页面越复杂越贵。
browser_find与--snapshot-mode none可以缓解。 - 不适合大批量重复操作:N 次点击 = N 次工具调用 + N 份快照。这类任务应当先抓接口再走接口(见第 7 章),或改用 Playwright Library / Test 写成脚本。
- 强依赖页面实现质量:无障碍树好不好,取决于站点的 ARIA 写法。老旧站点、Canvas 应用需要退回坐标点击或注入 JS。
- 适合的场景:探索式自动化(还不知道下一步点哪里)、需要边看页面边推理、一次性任务、录制与调试,以及「能看懂页面就能干活」的通用任务。
6.8 playwright-cli:CLI + Skills 路线
playwright-cli 是微软为编程 Agent 单独提供的命令行入口(npm 包名 @playwright/cli)。注意它与 npx playwright test 不是一回事:后者是测试运行器,前者是给 Agent 用的浏览器遥控器——一条命令一个动作,浏览器由后台守护进程(daemon)持有。
设计目标一句话:让「浏览器状态」待在磁盘和守护进程里,只把「结论」放进模型上下文。
6.8.1 省 token 的三条机制
- 工具 schema 不进上下文。MCP 在会话开始时
tools/list,把 25+ 个工具的 JSON Schema 常驻在后续每一次请求里;对 CLI 而言,模型手里的「工具」只有 shell 一个,命令语法通过 Skill 按需注入,或让 Agent 自己读--help发现。 - 输出落盘,不落上下文。每次命令之后,CLI 回到模型眼里的只有这么多:
### Page - Page URL: https://demo.playwright.dev/todomvc/#/ - Page Title: React • TodoMVC ### Snapshot [Snapshot](.playwright-cli/page-2026-02-14T19-22-42-679Z.yml)
读不读、读多少、只 grep 哪一段,全部由 Agent 自己决定——「看页面」从强制动作变成按需动作。 - 过期状态不堆积。MCP 的每一棵历史无障碍树都留在上下文里,而且已经失效(stale);CLI 的快照是磁盘文件,下一次命令直接覆盖,上下文里不留垃圾。
6.8.2 安装与 Skills
# 安装 CLI(需要 Node.js 20+) npm install -g @playwright/cli@latest playwright-cli --help # 安装 Skills:把命令用法注入编程 Agent playwright-cli install --skills # 装到 ~/.claude/skills playwright-cli install --skills -g # 装到全局(用户目录) playwright-cli install --skills=agents -g # 装到 ~/.agents/skills
Skill 的作用:把「有哪些命令、怎么组合」按需注入,避免 Agent 猜命令名、猜参数——猜错一次就浪费一个回合和一份输出。不装 Skill 也能跑(skills-less 操作):直接把 CLI 交给 Agent,并提示它读 playwright-cli --help 自行发现命令。
6.8.3 会话模型:状态在磁盘,不在上下文
「CLI 是无状态的」是常见误解。守护进程(daemon,常驻的辅助进程)持有浏览器,命令只是往它那儿发一次遥控:
playwright-cli open https://example.com --persistent # profile 落盘,跨进程保留 playwright-cli -s=example open https://example.com # 命名会话,多项目并行 playwright-cli list # 列出所有会话 playwright-cli show # 可视化面板:预览 + 接管鼠标键盘 playwright-cli close-all # 关闭全部会话 playwright-cli kill-all # 强制杀掉残留浏览器进程 PLAYWRIGHT_CLI_SESSION=todo-app claude . # 让 Agent 绑定到指定会话
- 默认 profile 在内存:会话内各命令之间保留 cookie 与 storage,浏览器一关就丢;
--persistent才落盘。 - 默认 headless 会话空闲 1 小时自动回收(
open --idle-timeout=<ms>或配置文件timeouts.idle,传 0 关闭);headed 与 attach 上来的浏览器不会被自动关闭。 - headless 不影响交互:没有窗口,也就不存在「Chrome 必须在前台」的问题;
--headed也由 Playwright 启动并带防节流参数,同样不需要抢占系统焦点。只有attach到你现有的 Chrome 时才需要保持窗口在前台(见 7.4)。
代价是:模型不「记得」页面结构,需要时得重新 snapshot 去读文件。这正是 MCP 在长链迭代推理里反而更省事、更稳的根因——它花 token 买了「记忆」。
6.8.4 命令速查
| 分类 | 主要命令 | 说明 |
|---|---|---|
| 打开与交互 | open [url]、goto、click <ref>、type <text>、fill <ref> <text>、select、check / uncheck、hover、drag、upload、close | 与 MCP 的核心自动化一一对应,都是「一个动作一条命令」 |
| 定位元素 | snapshot 取 ref → click e15 | 也支持 CSS 选择器、role=button[name=Submit] 这类 role 选择器,以及 >> 链式定位 |
| 快照与导出 | snapshot [--filename=f]、screenshot [ref] [--hires]、pdf | 快照默认写文件并只回传路径;也可只快照某个元素 |
| 导航 | go-back、go-forward、reload | — |
| 键鼠 | press <key>、keydown / keyup、mousemove <x> <y>、mousedown / mouseup、mousewheel <dx> <dy> | 无障碍树覆盖不到的界面(Canvas 等)退回坐标操作 |
| 标签页 | tab-list、tab-new、tab-select、tab-close | — |
| 网络 | requests、request <num>、route / route-list / unroute | 相当于 MCP 的 --caps=network,用来 Mock 与抓请求 |
| 存储 | state-save / state-load、cookie-*、localstorage-* | 登录态复用 |
| 调试与录制 | console、eval、run-code、tracing-start / tracing-stop、video-start / video-chapter / video-stop | 相当于 MCP 的 --caps=devtools |
| WebMCP | webmcp-list、webmcp-call <name> | 页面通过实验性的 WebMCP API 自己注册工具给 Agent 调;工具名、描述、schema 与结果都由页面提供,属不可信输入,不能当指令执行 |
6.8.5 使用纪律(决定你到底省不省)
- ref 会失效。任何改变 DOM 的动作(导航、AJAX 刷新、表单提交)之后必须重新 snapshot,旧 ref 立即无效——这是 CLI 路线最常见的错误。
- 不要整读快照文件。先按关键词搜,或用局部/浅层快照(限制深度、只取某个元素),把需要的那几行读进上下文即可。
- 清理
.playwright-cli/。快照会越积越多,别提交进 git。 - 一次任务一个命名会话。用
-s=隔离,收尾close-all,避免 headless 会话堆到 1 小时才自动回收。
6.9 实战演示:同一个任务,MCP 与 CLI 各花多少上下文
演示任务(取自官方 walkthrough 的同一场景):打开 https://demo.playwright.dev/todomvc/ → 输入 "Buy groceries" 回车 → 输入 "Water flowers" 回车 → 勾选第一项 → 截图。总共 5~6 个动作,属于「小任务」。
6.9.1 MCP 路线:每步回传整棵树
在 OpenCode 这类 Host 里,实际发生的是:
| 步骤 | 工具调用 | 回到上下文的内容 | 上下文增量 |
|---|---|---|---|
| 1 | browser_navigate | 动作后的完整无障碍树 | 一整棵树 |
| 2 | browser_type "Buy groceries" | 又一次完整快照 | 又一整棵树 |
| 3 | browser_press_key Enter | 又一次完整快照 | 又一整棵树 |
| 4 | browser_type "Water flowers" + press_key Enter | 每次动作后各一份快照 | 再两棵树 |
| 5 | browser_snapshot | 为拿到勾选框的 ref,再取一次全树 | 又一整棵树 |
| 6 | browser_click e21 | 动作后又一份快照 | 又一整棵树 |
| 7 | browser_take_screenshot | 图像本身进入上下文 | 图片 token(且官方明确它只能给人看,不能据此操作) |
三点必须记住:
- 每一步回传的都是整棵树,而且旧的树不会消失——成本随步数近似线性累加,上下文里大部分是已经过期的页面状态。
- 这个 5~6 步的小任务,配合工具 schema 与多轮快照,就是官方基准里 ≈114,000 tokens 那个量级的来源。
- 真正需要的往往只有「勾选框的 ref」这一行,其余都是被强制接收的。
6.9.2 CLI 路线:每步只回一行路径
playwright-cli open https://demo.playwright.dev/todomvc/ --headed playwright-cli type "Buy groceries" playwright-cli press Enter playwright-cli type "Water flowers" playwright-cli press Enter playwright-cli check e21 playwright-cli screenshot
每条命令回到上下文里的只有:动作是否成功 + 一行快照文件路径。命令里的 e21 也不必然需要先取一份全树——从最近一次快照文件里搜出这一行就够:
# 只需要一行 ref 时,不要整读快照文件 grep -n "checkbox" .playwright-cli/page-2026-02-14T19-22-42-679Z.yml # 也可以直接要一份局部快照(限制深度 / 指定元素) playwright-cli snapshot --depth=2 playwright-cli snapshot "#main"
6.9.3 两种路线的时序对照
6.9.4 逐维度对照
| 维度 | Playwright MCP | playwright-cli + Skills |
|---|---|---|
| 上下文常驻 | 25+ 工具的 JSON Schema,每次请求都带 | 一份 Skill 说明(按需加载),或零(skills-less) |
| 单次动作回传 | 完整无障碍树,内联进上下文 | 一行文件路径 + 页面 URL / Title |
| 历史状态 | 永久保留且已过期(stale),越积越多 | 文件覆盖/删除,上下文里不留痕 |
| 模型「记忆力」 | 强:页面结构就在上下文里,可直接多轮推理 | 弱:需要时得重新读快照(但读多少自己定) |
| 状态持久性 | Server 常驻进程,天然持久 | 守护进程 + 命名会话 / --persistent,同样能持久 |
| 环境要求 | 任何 MCP Host(含无 shell 的云沙箱) | 必须有 shell 与文件系统 |
| 可审计性 | 调用记录留在会话里 | 命令序列本身就是可复制的脚本 |
| 官方基准 | ≈114k tokens | ≈26.8k tokens(≈4x 更省) |
| 最适合 | 探索式自动化、自愈测试、长时自治循环 | 日常编程 Agent、批量重复、CI、上下文吃紧的长会话 |
6.10 选型决策
三条判据,按顺序问一遍即可:
- 宿主有没有 shell 与文件系统?没有(云沙箱、纯聊天界面)→ 只能 MCP。
- 上下文预算紧不紧?紧(长会话、大代码库)→ 优先 CLI。
- 需不需要「看一步、想一步」的迭代推理与长时自治?需要 → MCP。
| 你的情况 | 选哪条 | 理由 |
|---|---|---|
| 有 shell,任务路径已知 | CLI + Skills | 省掉 schema 与历史快照的常驻成本 |
| 大批量重复操作 / CI 里跑 | CLI,或直接 Library / Test | N 次动作不该等于 N 份无障碍树 |
| 长会话、上下文已经吃紧 | CLI | 节省随轮次放大 |
| 云沙箱、无 shell 的宿主 | MCP | 没有别的选择 |
| 探索式:还不知道下一步点哪里 | MCP | 需要带着整棵页面树做多轮判断 |
| 自愈测试、长时自治循环 | MCP | 状态在服务端,中途不断链 |
| 需要反复查 console / network / trace | MCP | 内省能力随叫随到,不必每次敲命令再看输出 |
两者底层是同一个引擎,不冲突:同时开会有两个独立的浏览器/会话,用命名会话(-s=)隔开即可。这就是官方「playwright-cli vs Playwright MCP」一节的全部结论——不是二选一,而是按场景分工。
7. 网页批量操作规范(实战)
playwright-cli open 自建的 headless / headed 实例不需要,见 7.4),否则动作一定会超时。
本章的结论来自一次真实任务:把网盘里 38 个文件按「知名度」筛选后搬进归档目录(全部用移动、不删除)。整个过程里约 90% 的时间浪费在用鼠标点击上——反复超时、反复调定位;而最后 38 个文件的搬迁,只是一次接口调用就完成了。下面是可复用的规范。
7.1 动手前的决策树
任务需要同一种操作重复 3 次以上? ├─ 是 → 先找写接口(hook 抓一次真实请求)→ 之后全部走接口 └─ 否 → 走 UI 点击(但必须通过 6.4 的检查清单)
判断「有没有写接口」不要靠反复试参数(本次试了 6 组参数组合,全部返回 errno 2,纯属浪费),而要靠抓一次真实请求:让 UI 成功执行一次,把完整请求记下来。这一次点击是投资,不是浪费。
只读接口(列表、详情)几乎总能直接调用,先用它做库存盘点,不要靠 UI 数数。
7.2 抓接口套路:一次点击换 N 次点击
// ① 注入 hook(在页面内执行;注意 location.reload() 后会失效,必须重打)
window.__reqs = [];
const of = window.fetch;
window.fetch = function (...a) {
window.__reqs.push({
u: typeof a[0] === 'string' ? a[0] : a[0]?.url,
b: a[1]?.body ? String(a[1].body).slice(0, 2000) : null,
m: a[1]?.method || 'GET',
});
return of.apply(this, a);
};
const oo = XMLHttpRequest.prototype.open, os = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.open = function (m, u, ...r) { this.__u = m + ' ' + u; return oo.call(this, m, u, ...r); };
XMLHttpRequest.prototype.send = function (b) { window.__reqs.push({ u: this.__u, b: b ? String(b).slice(0, 2000) : null }); return os.call(this, b); };
// ② 用 UI 真实执行一次,然后读出请求
(window.__reqs || []).filter(r => /filemanager/.test(r.u || ''))
// 关键发现:filelist 必须是 [{"path":"...","dest":"...","newname":"..."}] 对象数组,
// 而不是路径字符串数组 —— 这才是之前 errno 2 的真正原因
// ③ 之后所有操作都在页面内 fetch,credentials:'include' 自动带 Cookie
await fetch('/api/filemanager?opera=move&bdstoken=...&async=2&onnest=fail&web=1', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'filelist=' + encodeURIComponent(JSON.stringify(items)),
});
| 要点 | 说明 |
|---|---|
| body 的结构才是成败关键 | URL 上的 bdstoken 只是身份参数,真正的参数格式在 body 里;抓到了就照抄,不要自己猜 |
| 批量提交 | 一次 filelist 可以放 N 个对象,一次请求搬完一个目录(实测 23 个文件一把成功) |
| 反爬层 | 许多站点有 AntiRequest.getAntiParams() 一类的签名层;靠「抓一次真实请求」直接复用其参数,比逆向签名快得多 |
| 写操作后必校验 | 用只读接口回读确认,不要相信返回值里的一行 errno:0 |
7.3 MCP 工具使用规范
| 事项 | 规范 | 原因 |
|---|---|---|
| 返回值解析 | 包一层 helper,从 ### Result 与 ### Ran Playwright code 之间抽出 JSON | 每次手写正则很浪费时间 |
| 元素定位 | 优先用 browser_evaluate 给目标节点注入稳定 id,再用 browser_click 按选择器点击 | 避免依赖快照 ref;虚拟列表滚动后行会重渲染 |
| 快照 | 少用 browser_snapshot | 体积大、慢,且会生成 yml 文件污染仓库 |
| 等待 | 用 browser_wait_for({time:...}) 做短等 | SPA 无法靠网络空闲判断就绪 |
| 点击超时 | 默认 5s(--timeout-action),不要指望调大,要消除「不可见 / 不稳定」的根因 | 见 7.4 |
7.4 必须走 UI 时的硬性检查清单(缺一必失败)
attach --cdp=chrome / 扩展 attach)成立——这个 Chrome 是你自己启动的,没有 Playwright 的防节流参数。用 playwright-cli open 自己启动的浏览器不需要:默认就是 headless(连窗口都没有),--headed 时 Playwright 也会带上 --disable-background-timer-throttling、--disable-backgrounding-occluded-windows、--disable-renderer-backgrounding,并开启焦点模拟(Emulation.setFocusEmulationEnabled)。实测切到别的 App 后 rAF 仍是满帧、document.hasFocus() 为 true。真正的分界是「复用你的浏览器」还是「Playwright 自己开的浏览器」。至于怎么 attach 上你的 Chrome(Chrome 136+ 已不允许默认 profile 开调试端口),见 6.6.1。
| 启动方式 | 需要保前台 / 不遮挡? | 原因 |
|---|---|---|
open(默认 headless) | 否 | 无窗口;帧循环照常跑,实测 62 FPS |
open --headed / open --browser=chrome | 否 | 自建实例带防节流参数,输入走 CDP(OS 焦点无关) |
attach --cdp=chrome(需在 chrome://inspect 勾选允许)/ --extension | 是 | 原样继承系统后台/遮挡节流,rAF 掉到约 2 FPS |
| 原生对话框(文件选择 / 权限 / 打印 / 下载确认) | 是 | OS 层交互,Playwright 管不到;能绕则用 upload、grantPermissions、acceptDownloads |
- (attach 模式)窗口必须在前台且未被遮挡 —— 第一杀手。
osascript -e 'tell application "Google Chrome" to activate' # 然后测帧率:超过 30 帧 / 300ms 才算可用
attach 上来的 Chrome 被切到后台或被遮挡时,requestAnimationFrame会被节流到约 2 FPS,Playwright 的「元素稳定」检查永远不通过,动作必然 5 秒超时——这不是选择器写错,改选择器没用。自建浏览器不存在这个问题,别把 attach 的经验套到 headless 上。 - (attach 模式)先测帧率再动手,不要等超时了才发现窗口被切走。
visibility:hidden的元素点不到(典型如鼠标悬停才显形的复选框),注入样式强制可见:const st = document.createElement('style'); st.textContent = 'label.hide-checkbox{visibility:visible!important;opacity:1!important}'; document.head.appendChild(st);- 虚拟滚动列表:DOM 里只保留屏幕内约 22 行,视口外的行根本不存在。可按序号滚动定位(
scrollTop = idx * rowH - 400),滚动后必须重新取元素;更好的做法是直接改走接口。 - 不要点文件名单元格 —— 会打开在线预览并新建标签页;要点行首的复选框单元格
td:nth-child(1)。 - 每步之后校验状态(如
is-checked、表头「已选中 N 个文件/文件夹」),不要盲点。
7.5 元素定位规范
- 不要用「行文本」当唯一标识:同一行在 DOM 中的文本可能被重复拼接,导致同一行被点两次 = 取消勾选。(真实事故:程序报告「点了 41 次」,实际只剩 1 个选中。)
- 正确做法:用接口返回的精确文件名(
server_filename)做比对,命中后再给该 DOM 节点临时打上id供点击。 - 每次
location.reload()之后,注入的 hook、样式、临时 id 全部失效,必须重新注入。
7.6 验收与反模式
双向校验:源目录减少 N 个 == 目标目录增加 N 个,不能只看一边。临时产生的中间对象(本次为搬运建的 _tmp_brian_probe 文件夹)必须显式删除并复核。
| 反模式 | 问题 | 正确做法 |
|---|---|---|
| 反复试接口参数组合 | 试 6 次不如抓 1 次 | hook 抓一次真实请求后照抄 |
| attach 的 Chrome 在后台时反复重试点击 | rAF 被节流到 2 FPS,必超时 | 先置前并测帧率(自建浏览器无此问题) |
| 用行文本 / 序号去重 | 重复文本导致误取消勾选 | 用接口返回的精确文件名 |
反复拉 browser_snapshot | 慢,且产生垃圾 yml 文件 | 用 browser_evaluate 注入稳定 id |
| attach 模式下边自动化边用同一个浏览器做别的事 | 抢焦点,动作必失败 | 专用 profile;或干脆改走接口 |
| 在用户日常浏览器实例上跑自动化 | 可能崩溃、污染用户会话(本次崩过一次:Google Chrome-2026-10-17-085355.ips) | --user-data-dir=~/.chrome-debug --remote-debugging-port=9222 |
7.7 案例复盘:百度网盘批量归档
| 现象 | 根因 | 正确做法 |
|---|---|---|
写接口一直返回 errno 2 | filelist 应为对象数组,实际传了字符串数组 | 抓一次真实请求照抄参数 |
| 点击 5 秒超时 | attach 的 Chrome 被切到后台,rAF 掉到 2 FPS | 置前 + 测帧率后再点(自建 headless 无此问题) |
| 复选框点不到 | 默认 visibility:hidden,悬停才显形 | 注入 CSS 强制可见 |
| 滚动的行点不到 | 虚拟列表只渲染屏内约 22 行 | 按序号滚动后重新取元素,或改走接口 |
| 点 41 次只剩 1 个选中 | 用行文本去重,重复文本导致二次点击 | 用精确文件名定位 |
| 最终 38 个文件搬迁 | — | 抓到参数后,一次接口调用批量完成 |
点击次数 × 单次失败率 × 重试成本。把点击次数压到 1(抓参数)或 0(走接口),比优化单次点击的写法有效得多。